Skip to content

trade execute: bind EVM receipt confirmation to the locally-derived tx hash - #519

Merged
kome12 merged 8 commits into
mainfrom
fix/evm-execute-receipt-txhash-binding
Aug 26, 2026
Merged

trade execute: bind EVM receipt confirmation to the locally-derived tx hash#519
kome12 merged 8 commits into
mainfrom
fix/evm-execute-receipt-txhash-binding

Conversation

@kome12

@kome12 kome12 commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

Summary

  • trade execute confirmed a broadcast EVM transaction by polling waitForReceipt with whatever txHash the broadcaster reported, without ever checking it against the transaction the CLI actually signed. A compromised or buggy broadcaster could report success for a different transaction, and the CLI would proceed to the next step (e.g. broadcasting a swap after a "confirmed" allowance revoke that never happened on-chain).
  • Add evmTxHash (keccak256 over the raw signed tx bytes) and confirmEvmBroadcast, which polls the receipt on our own locally-derived hash and fails closed with a clear, actionable error if the broadcaster's reported hash disagrees.
  • Apply this at every EVM executeTransaction/waitForReceipt pair in trade execute — the swap broadcast, ordinary approvals, and the revoke-then-reapprove flow — across the Privy, WalletConnect, and local-key signing paths.
  • Carve out gasless swaps: the Relay solver broadcasts its own on-chain transaction there, so the returned hash is legitimately different from the bytes we signed; that path still confirms on the broadcaster's hash as before.
  • TXHASH_MISMATCH is now fatal (re-thrown) everywhere it can occur, instead of being swallowed into "try the next quote" — a broadcaster-integrity failure shouldn't be treated like a bad quote.
  • Left the WalletConnect wallet-broadcast swap path and src/bridge.js unchanged (out of scope — no raw signed bytes available in the former; different broadcast mechanism in the latter).

Test plan

  • npm test — 2127 tests passing, including new evmTxHash unit tests (known-vector, hex normalization, rejection cases, EIP-1559/legacy signing round-trips), a fail-closed mismatch test, and a gasless-regression test
  • npm run lint
  • Existing EVM execute test mocks updated to echo the real hash of the signed bytes, as a correct broadcaster would
  • Ran the actual CLI (node src/index.js trade quote / trade execute) against a local mock trading-api + RPC server: a correct broadcaster confirms normally, a broadcaster returning a mismatched hash is rejected with the fail-closed error, before any success is reported

🤖 Generated with Claude Code

trade execute confirmed a broadcast transaction by polling
waitForReceipt with whatever txHash the broadcaster reported, without
checking it against the transaction the CLI actually signed. A
compromised or buggy broadcaster could report success for a different
transaction, and the CLI would proceed to the next step (e.g.
broadcasting a swap after a "confirmed" allowance revoke that never
happened on-chain).

Add evmTxHash (keccak256 of the signed tx bytes) and confirmEvmBroadcast,
which polls the receipt on our own locally-derived hash and fails closed
if the broadcaster's reported hash disagrees. Apply it at every EVM
executeTransaction/waitForReceipt pair in trade execute (swap, approval,
and revoke-then-reapprove, across the Privy/WalletConnect/local-key
paths), with a carve-out for gasless swaps where the Relay solver
broadcasts its own transaction and the returned hash is legitimately
different.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@nansen-pr-reviewer

nansen-pr-reviewer Bot commented Aug 25, 2026

Copy link
Copy Markdown

pr-reviewer Summary for #64088d8

No issues found

The code review completed successfully with no findings.

Review effort: 4/5 (Complex)

Summary

This PR closes a real security gap: previously, trade execute confirmed EVM transactions by polling waitForReceipt on the broadcaster's reported hash, not the hash of the bytes the CLI actually signed. A compromised or buggy broadcaster could falsely confirm a different transaction. The fix is well-designed and correctly applied.

Overall assessment: The implementation is sound. evmTxHash is a clean, well-validated keccak256 wrapper. assertTxHashMatch normalizes prefix-insensitively before comparing (preventing false mismatches on bare vs. 0x-prefixed hashes) and wraps derivation failures as INVALID_SIGNED_TX with CommandError, so isFatalBroadcastError correctly propagates them. The RECEIPT_TIMEOUT distinction from a confirmed on-chain revert is the right call — preventing the duplicate-broadcast scenario that "try the next quote" would otherwise create. Coverage of all three signing paths (Privy, WalletConnect-via-Trading-API, local-key) is consistent. The gasless Relay carve-out (where the solver broadcasts its own on-chain tx) is correctly scoped and regression-tested.

A few things were verified to be non-issues:

  • The apparent double call to evmTxHash at lines 3223 and 3235 (inside confirmEvmBroadcast) is intentional: the first call sets txId/explorerUrl for use in catch error messages before the receipt poll, while the second call (inside confirmEvmBroadcast) is the actual gate. Redundant but harmless.
  • WalletConnect wallet-direct-broadcast paths that lack raw signed bytes (lines 2711, 2775, 2862) all have isFatalBroadcastError guards, so RECEIPT_TIMEOUT is re-thrown rather than swallowed to "try next quote" — consistent with the PR's goal even without hash binding.
  • The changeset is present and correctly typed as patch (security bug fix to existing behavior). No new commands or options were added, so schema.json does not need updating.
  • Test suite exercises all new code paths: known-vector hash correctness, mismatch-fatal behavior, no-broadcaster-hash polling guarantee, gasless regression, receipt-timeout abort, and revoke-timeout abort — without hitting real networks.

Token usage: 8,466 input, 4,646 output, 907,504 cache read, 56,522 cache write | Usage Guide

New pushes are reviewed automatically with a 10-minute cooldown between reviews. To request a review at any time, comment @nansen-pr-reviewer re-review.

kome12 and others added 6 commits August 25, 2026 18:07
…path

Fold the duplicated TXHASH_MISMATCH check (confirmEvmBroadcast plus the two
inline WalletConnect broadcast sites) into a single assertTxHashMatch helper
that returns the locally-derived hash and fails closed on a mismatch. No
behavior change — the WalletConnect sites already bound their receipt wait to
the local hash; this just removes the copy-pasted error message.

Add a unit test for guarantee #2: when the broadcaster returns no hash,
confirmEvmBroadcast polls OUR locally-derived hash, never a foreign hash that
happens to have a receipt.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Addresses pr-reviewer's finding on #519: the four confirmEvmBroadcast
success-log lines printed the broadcaster's reported txHash, while the
receipt was actually polled on our locally-derived hash. They are
identical whenever the equality check passes (always, today), so the log
was never wrong — but it showed a value we hadn't independently verified.

Return the local hash from confirmEvmBroadcast alongside the receipt and
log that instead, so the success line always names the transaction we
confirmed landed. Extend the guarantee-#2 test to assert the returned
hash is our local hash.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The main EVM swap block dropped its `&& result.txHash` guard so the
non-gasless path can fall back to polling our locally-derived hash
(guarantee #2). But the gasless branch has no local hash to bind to —
the Relay solver broadcasts its own tx — so a gasless success with no
reported hash would call waitForReceipt(chain, undefined), poll
eth_getTransactionReceipt([undefined]) for the full 180s timeout, and
then falsely report a revert.

Restore the pre-existing skip for the gasless branch only: poll a
receipt when the solver reports a hash, otherwise skip. Non-gasless
behavior is unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The swap-path and allowance-path confirmEvmBroadcast/assertTxHashMatch
sites all rethrow TXHASH_MISMATCH, but every one of those rethrows
propagated to the quote loop's catch (quoteErr), which logged "Quote
failed" and moved on to the next quote (or ended in ALL_QUOTES_FAILED).
So a broadcaster-integrity failure was still swallowed and treated like
a bad quote, contradicting the guarantee that it fails closed.

Rethrow TXHASH_MISMATCH out of the quote-retry catch so it aborts the
whole execute instead of falling through to the next quote.

The prior single-quote test passed against the swallow bug because its
message assertion also matched the ALL_QUOTES_FAILED wrapper. Strengthen
it: a second quote (so a swallow would visibly retry), assert the thrown
code is TXHASH_MISMATCH, assert only one /execute body was sent, and
assert no "Trying next quote" log.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Addresses pr-reviewer's finding on #519: evmTxHash(signedTransaction)
was called inside the receipt-poll try whose catch prints "Transaction
was broadcast but REVERTED on-chain!". A signedTransaction that is not
valid hex throws before any poll runs, so that near-impossible path
would show a revert diagnostic for a transaction that was never polled.

Derive the local hash in a guarded pre-step outside the poll try: a
hex-validation failure now surfaces as INVALID_SIGNED_TX with its real
message, and the revert catch only ever fires for an actual poll. Also
drops the redundant in-try evmTxHash call (confirmEvmBroadcast recomputes
it) and keeps the single shared revert/next-quote handler for both the
gasless and non-gasless branches.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@kome12

kome12 commented Aug 26, 2026

Copy link
Copy Markdown
Contributor Author

@nansen-pr-reviewer re-review

@hulk-linus hulk-linus Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Retracted — this review was broader than the PR scope and is superseded by the scoped exact-head review: #519 (review)

Only F1 and F4 are being requested for this PR. F2 and F5 are follow-ups; F3 is withdrawn.

@Codier Codier left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I took another pass on scope. I think only these two findings should be addressed in this PR.

F1 — Treat every post-broadcast local-hash failure as fatal

At src/trading.js:3162-3164, INVALID_SIGNED_TX can be raised after /execute has already returned Success, but the quote boundary at :3247 only treats TXHASH_MISMATCH as fatal. The approval/revoke paths have the same shape: local hash derivation happens after a successful broadcast, while their catches only rethrow the mismatch code.

At that point the broadcast outcome is uncertain. Trying the next quote can submit another transaction, which is exactly the behavior this PR is trying to prevent.

Please either derive/validate the local hash before calling /execute, or make every local-hash derivation failure after a successful response fatal across the swap, approval, and revoke paths. Add a two-quote regression proving there is only one /execute call and the original error reaches the user.

F4 — A receipt timeout is not a confirmed revert and must not retry

waitForReceipt distinguishes a confirmed on-chain revert from “receipt not found after 180000ms”, but the catch at src/trading.js:3178-3187 renders both as REVERTED on-chain and tries the next quote.

This directly weakens the PR's new no-broadcaster-hash guarantee: a silent substitution is supposed to time out while polling our local hash, but that timeout currently causes another swap to be broadcast. A legitimately pending transaction has the same duplicate-broadcast risk.

Please distinguish confirmed revert from timeout/unconfirmed receipt. A confirmed revert may follow the existing retry behavior; a timeout or RPC-unconfirmed result is uncertain post-broadcast state and should abort. Add a two-quote regression proving the timeout path makes only one /execute call and never logs “Trying next quote”.

Exact-head review of e536da7ff18b2a5f765274fec8b09d3c2f8b5a87 · review-only · OpenAI GPT-5.6 Sol

@Codier

Codier commented Aug 26, 2026

Copy link
Copy Markdown
Collaborator

Closing loops on F1 and F4 findings from the GPT-5.6 Sol review.

F1: fatal local-hash derivation failures

You addressed this for the main swap path by moving evmTxHash derivation outside the receipt catch at src/trading.js:3161, which correctly surfaces INVALID_SIGNED_TX.

However, the approval and revoke paths in both the Privy and local-key signers (e.g., src/trading.js:2331, :2390, :2973, :3025) still call confirmEvmBroadcast (which internally calls evmTxHash) inside try-catches that log an error and continue to the next quote. If evmTxHash throws there after a successful broadcast, the outcome is uncertain and a duplicate broadcast could occur.

Please ensure that evmTxHash (via confirmEvmBroadcast or direct call) is treated as fatal if it fails after a successful broadcast across all paths.

F4: timeout is not a revert

In src/trading.js:685, waitForReceipt throws a generic Error on timeout. All callers (:2335, :2394, :2977, :3029, :3180) catch this, log it as a revert or unconfirmed status, and retry the next quote.

A timeout (uncertain state) must not retry. Please update waitForReceipt to throw a distinguishable error for timeouts (e.g., RECEIPT_TIMEOUT) and ensure callers only retry on confirmed on-chain reverts.

I've verified the current tests pass locally, but we need these specific edge cases covered and enforced to close the trust boundary.

Addresses the GPT-5.6 Sol review and Codier's follow-up on #519. Once a
transaction is broadcast, "try the next quote" can broadcast a second
transaction while the first's fate is unknown — the failure class this
PR exists to close. Three gaps remained where the CLI still moved past
an unverified broadcast:

- INVALID_SIGNED_TX (F1): a local-hash derivation failure after a
  successful broadcast was only fatal on the main swap path. Route it
  through assertTxHashMatch as a coded error and treat it as fatal at
  every swap/approval/revoke catch (Privy, WalletConnect, local-key) via
  a shared isFatalBroadcastError helper.

- RECEIPT_TIMEOUT (F4): waitForReceipt threw an untyped Error on timeout,
  which every caller rendered as "REVERTED on-chain" and retried. A
  timeout is uncertain, not a confirmed revert — retrying races a second
  tx against the same nonce. Tag the timeout with a code and fail closed
  on it everywhere, keeping the retry only for a genuine on-chain revert.

- TXHASH_MISMATCH prefix sensitivity (F3): the comparison was
  0x-prefix-sensitive while evmTxHash always emits 0x-prefixed, so a
  bare-hex broadcaster hash was a false (now-fatal) mismatch. Normalize
  both sides before comparing.

Also forward the label through confirmEvmBroadcast so a Privy/local-key
revoke mismatch names itself — the least useful moment to lose context.

Tests: bare-hex no-false-mismatch, RECEIPT_TIMEOUT tagging, and two-quote
regressions proving the swap-timeout, revoke-timeout, and INVALID_SIGNED_TX
paths each make exactly one /execute call and never try the next quote.
Verified end-to-end against a local mock via the real `trade execute`
binary: a mismatched broadcaster hash aborts with TXHASH_MISMATCH, and a
never-landing receipt aborts with RECEIPT_TIMEOUT — both before any
success is reported.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

@Codier Codier left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good. You've correctly ensured that any post-broadcast uncertainty — whether it's a hash mismatch, a derivation failure, or a receipt timeout — is treated as fatal across all paths (swap, approval, revoke). This closes the double-broadcast risk.

CI is green and the new test coverage for these edge cases is solid.

Approving.

AI execution metadata

  • Provider: google
  • Model: gemini-3-flash-preview
  • Thinking level: high
  • Attribution: exact-head review of 64088d81a5ed18161ae349feec18aaf53c51e531

@kome12
kome12 merged commit fb8815f into main Aug 26, 2026
9 checks passed
@kome12
kome12 deleted the fix/evm-execute-receipt-txhash-binding branch August 26, 2026 09:23
@github-actions github-actions Bot mentioned this pull request Aug 26, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants